/* ******************************************************************************
 * Copyright (c) 2006-2016 XMind Ltd. and others.
 * 
 * This file is a part of XMind 3. XMind releases 3 and
 * above are dual-licensed under the Eclipse Public License (EPL),
 * which is available at http://www.eclipse.org/legal/epl-v10.html
 * and the GNU Lesser General Public License (LGPL), 
 * which is available at http://www.gnu.org/licenses/lgpl.html
 * See http://www.xmind.net/license.html for details.
 * 
 * Contributors:
 *     XMind Ltd. - initial API and implementation
 *******************************************************************************/
package org.xmind.core.internal.dom;

import static org.xmind.core.internal.zip.ArchiveConstants.COMMENTS_XML;
import static org.xmind.core.internal.zip.ArchiveConstants.CONTENT_XML;
import static org.xmind.core.internal.zip.ArchiveConstants.MANIFEST_XML;
import static org.xmind.core.internal.zip.ArchiveConstants.META_XML;
import static org.xmind.core.internal.zip.ArchiveConstants.PATH_MARKER_SHEET;
import static org.xmind.core.internal.zip.ArchiveConstants.PATH_REVISIONS;
import static org.xmind.core.internal.zip.ArchiveConstants.REVISIONS_XML;
import static org.xmind.core.internal.zip.ArchiveConstants.STYLES_XML;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.zip.ZipOutputStream;

import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.dom.DOMResult;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.xmind.core.Core;
import org.xmind.core.CoreException;
import org.xmind.core.IAdaptable;
import org.xmind.core.ICommentManager;
import org.xmind.core.IEntryStreamNormalizer;
import org.xmind.core.IFileEntry;
import org.xmind.core.IMeta;
import org.xmind.core.IRevisionManager;
import org.xmind.core.IRevisionRepository;
import org.xmind.core.ISerializer;
import org.xmind.core.IWorkbook;
import org.xmind.core.internal.AbstractSerializingBase;
import org.xmind.core.internal.zip.ArchiveConstants;
import org.xmind.core.internal.zip.ZipStreamOutputTarget;
import org.xmind.core.io.ByteArrayStorage;
import org.xmind.core.io.IInputSource;
import org.xmind.core.io.IOutputTarget;
import org.xmind.core.io.IStorage;
import org.xmind.core.marker.IMarkerSheet;
import org.xmind.core.style.IStyleSheet;
import org.xmind.core.util.DOMUtils;
import org.xmind.core.util.FileUtils;
import org.xmind.core.util.IProgressReporter;

/**
 * 
 * @author Frank Shaka
 * @since 3.6.50
 */
public class SerializerImpl extends AbstractSerializingBase
        implements ISerializer {

    private IWorkbook workbook;
    private IOutputTarget outputTarget;

    private final Set<String> encryptionIgnoredEntries;
    private String[] preferredEncryptionIgnoredEntries;

    private ZipOutputStream intermediateOutputStream;

    private boolean compressed;

    private boolean usesWorkbookStorageAsOutputTarget;

    private ManifestImpl manifest;

    private ManifestImpl tempManifest;

    private final Set<String> serializedEntryPaths;

    public SerializerImpl() {
        super();
        this.workbook = null;
        this.outputTarget = null;
        this.encryptionIgnoredEntries = new HashSet<String>();
        this.preferredEncryptionIgnoredEntries = null;
        this.intermediateOutputStream = null;
        this.compressed = false;
        this.usesWorkbookStorageAsOutputTarget = false;
        this.manifest = null;
        this.tempManifest = null;
        this.serializedEntryPaths = new HashSet<String>();
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xmind.core.ISerializer#getWorkbook()
     */
    public IWorkbook getWorkbook() {
        return workbook;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xmind.core.ISerializer#setWorkbook(org.xmind.core.IWorkbook)
     */
    public void setWorkbook(IWorkbook workbook) {
        if (workbook == null)
            throw new IllegalArgumentException("Workbook is null"); //$NON-NLS-1$
        if (!(workbook instanceof WorkbookImpl))
            throw new IllegalArgumentException("Can't serialize this workbook"); //$NON-NLS-1$
        IStorage storage = null;
        if (usesWorkbookStorageAsOutputTarget) {
            storage = workbook.getAdapter(IStorage.class);
            if (storage == null)
                throw new IllegalArgumentException(
                        "No workbook storage available"); //$NON-NLS-1$
        }
        this.workbook = workbook;
        if (storage != null) {
            doSetOutputTarget(storage.getOutputTarget());
        }
    }

    protected IOutputTarget getOutputTarget() {
        return this.outputTarget;
    }

    protected void doSetOutputTarget(IOutputTarget target) {
        this.outputTarget = target;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xmind.core.ISerializer#hasOutputTarget()
     */
    public boolean hasOutputTarget() {
        return outputTarget != null;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xmind.core.ISerializer#setOutputTarget(org.xmind.core.io.
     * IOutputTarget)
     */
    public void setOutputTarget(IOutputTarget target) {
        if (target == null)
            throw new IllegalArgumentException("output target is null"); //$NON-NLS-1$
        doSetOutputTarget(target);
        this.intermediateOutputStream = null;
        this.usesWorkbookStorageAsOutputTarget = false;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xmind.core.ISerializer#setOutputStream(java.io.OutputStream)
     */
    public void setOutputStream(OutputStream stream) {
        if (stream == null)
            throw new IllegalArgumentException("stream is null"); //$NON-NLS-1$
        this.intermediateOutputStream = new ZipOutputStream(stream);
        doSetOutputTarget(new ZipStreamOutputTarget(intermediateOutputStream,
                compressed));
        this.usesWorkbookStorageAsOutputTarget = false;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xmind.core.ISerializer#setWorkbookStorageAsOutputTarget()
     */
    public void setWorkbookStorageAsOutputTarget() {
        if (getWorkbook() != null) {
            IStorage storage = getWorkbook().getAdapter(IStorage.class);
            if (storage == null)
                throw new IllegalArgumentException(
                        "no workbook storage available"); //$NON-NLS-1$
            doSetOutputTarget(storage.getOutputTarget());
        } else {
            /// sets a fake output target that will be substituted when
            /// workbook is set
            doSetOutputTarget(new ByteArrayStorage().getOutputTarget());
        }
        this.usesWorkbookStorageAsOutputTarget = true;
        this.intermediateOutputStream = null;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xmind.core.ISerializer#getEncryptionIgnoredEntries()
     */
    public String[] getEncryptionIgnoredEntries() {
        return preferredEncryptionIgnoredEntries;
    }

    /*
     * (non-Javadoc)
     * 
     * @see
     * org.xmind.core.ISerializer#setEncryptionIgnoredEntries(java.lang.String[]
     * )
     */
    public void setEncryptionIgnoredEntries(String[] entryPaths) {
        this.preferredEncryptionIgnoredEntries = entryPaths;
        encryptionIgnoredEntries.clear();
        collectDefaultEncryptionIgnoredEntries(encryptionIgnoredEntries);
        if (entryPaths != null) {
            encryptionIgnoredEntries.addAll(Arrays.asList(entryPaths));
        }
    }

    protected boolean isEntryEncryptionIgnored(String entryPath) {
        return encryptionIgnoredEntries.contains(entryPath);
    }

    protected void collectDefaultEncryptionIgnoredEntries(
            Set<String> entryPaths) {
        entryPaths.add(ArchiveConstants.MANIFEST_XML);
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.xmind.core.ISerializer#serialize(org.xmind.core.util.
     * IProgressReporter)
     */
    public void serialize(IProgressReporter reporter)
            throws IOException, CoreException, IllegalStateException {
        WorkbookImpl workbook = (WorkbookImpl) getWorkbook();
        if (workbook == null)
            throw new IllegalStateException("no workbook to serialize"); //$NON-NLS-1$

        if (!hasOutputTarget())
            throw new IllegalStateException("no output target specified"); //$NON-NLS-1$

        manifest = workbook.getManifest();

        IEntryStreamNormalizer oldNormalizer = manifest.getStreamNormalizer();
        IEntryStreamNormalizer newNormalizer = getEntryStreamNormalizer();
        boolean normalizerChanged = newNormalizer != null
                && !newNormalizer.equals(oldNormalizer);

        if (usesWorkbookStorageAsOutputTarget) {
            tempManifest = manifest;
            if (normalizerChanged) {
                /// use new normalizer to save XML files
                tempManifest.setStreamNormalizer(newNormalizer);
            }
        } else {
            Document tempImplementation = cloneDocument(
                    manifest.getImplementation(),
                    ArchiveConstants.MANIFEST_XML);
            tempManifest = new ManifestImpl(tempImplementation,
                    new WriteOnlyStorage(getOutputTarget()));
            if (newNormalizer != null) {
                tempManifest.setStreamNormalizer(newNormalizer);
            } else {
                tempManifest.setStreamNormalizer(oldNormalizer);
            }

            /// Give this manifest a temp owner workbook to prevent null 
            /// pointer exception when file entry events are triggered.
            new WorkbookImpl(DOMUtils.createDocument(), tempManifest);
        }

        /// save meta.xml
        IMeta meta = workbook.getMeta();
        String creatorName = getCreatorName();
        if (creatorName != null)
            meta.setValue(IMeta.CREATOR_NAME, creatorName);
        String creatorVersion = getCreatorVersion();
        if (creatorVersion != null)
            meta.setValue(IMeta.CREATOR_VERSION, creatorVersion);
        serializeXML(meta, META_XML);

        /// save content.xml
        serializeXML(workbook, CONTENT_XML);

        /// NOTE: XML files should always serialized when saving to the 
        /// workbook's temp storage, otherwise recovered workbooks may contain
        /// invalid data.

        /// save markers/markerSheet.xml
        IMarkerSheet markerSheet = workbook.getMarkerSheet();
        if (usesWorkbookStorageAsOutputTarget || !markerSheet.isEmpty()) {
            serializeXML(markerSheet, PATH_MARKER_SHEET);
        } else {
            tempManifest.deleteFileEntry(PATH_MARKER_SHEET);
            serializedEntryPaths.add(PATH_MARKER_SHEET);
        }

        /// save styles.xml
        IStyleSheet styleSheet = workbook.getStyleSheet();
        if (usesWorkbookStorageAsOutputTarget || !styleSheet.isEmpty()) {
            serializeXML(styleSheet, STYLES_XML);
        } else {
            tempManifest.deleteFileEntry(STYLES_XML);
            serializedEntryPaths.add(STYLES_XML);
        }

        /// save comments.xml
        ICommentManager commentManager = workbook.getCommentManager();
        if (usesWorkbookStorageAsOutputTarget || !commentManager.isEmpty()) {
            serializeXML(commentManager, COMMENTS_XML);
        } else {
            tempManifest.deleteFileEntry(COMMENTS_XML);
            serializedEntryPaths.add(COMMENTS_XML);
        }

        /// save revisions
        IRevisionRepository revisionRepository = workbook
                .getRevisionRepository();
        for (String resourceId : revisionRepository
                .getRegisteredResourceIds()) {
            IRevisionManager manager = revisionRepository
                    .getRegisteredRevisionManager(resourceId);
            if (manager != null) {
                String path = PATH_REVISIONS + resourceId + "/" //$NON-NLS-1$
                        + REVISIONS_XML;
                serializeXML(manager, path);
            }
        }

        /// copy remaining file entries, e.g. attachments, etc.
        Iterator<IFileEntry> sourceEntryIter;
        if (!usesWorkbookStorageAsOutputTarget) {
            /// saving to external location,
            /// write only referenced file entries
            sourceEntryIter = manifest.iterFileEntries();
        } else if (normalizerChanged) {
            /// saving to internal storage when encryption is changed,
            /// re-encrypt all file entries
            sourceEntryIter = manifest.getAllRegisteredEntries().iterator();
        } else {
            /// saving to internal storage when encryption is not changed,
            /// touch no file entries
            sourceEntryIter = null;
        }

        while (sourceEntryIter != null && sourceEntryIter.hasNext()) {
            IFileEntry sourceEntry = sourceEntryIter.next();
            if (sourceEntry.isDirectory() || !sourceEntry.canRead())
                continue;

            String entryPath = sourceEntry.getPath();
            if (MANIFEST_XML.equals(entryPath)
                    || serializedEntryPaths.contains(entryPath))
                continue;

            IFileEntry targetEntry = tempManifest.getFileEntry(entryPath);
            if (targetEntry == null)
                // TODO missing entry, need log?
                continue;

            if (usesWorkbookStorageAsOutputTarget) {
                /// saving to internal storage,
                /// write to a temporary entry first to protect original entry
                String tempEntryPath = makeTempPath(entryPath);

                /// make sure we use the old normalizer to decrypt the file entry
                manifest.setStreamNormalizer(oldNormalizer);
                InputStream entryInput = sourceEntry.openInputStream();
                try {
                    OutputStream tempOutput = tempManifest.getStorage()
                            .getOutputTarget().openEntryStream(tempEntryPath);
                    try {
                        FileUtils.transfer(entryInput, tempOutput, false);
                    } finally {
                        tempOutput.close();
                    }
                } finally {
                    entryInput.close();
                }

                /// make sure we use the new normalizer to encrypt the file entry
                tempManifest.setStreamNormalizer(newNormalizer);
                InputStream tempInput = tempManifest.getStorage()
                        .getInputSource().openEntryStream(tempEntryPath);
                try {
                    OutputStream entryOutput = openEntryOutputStream(entryPath);
                    try {
                        FileUtils.transfer(tempInput, entryOutput, false);
                    } finally {
                        entryOutput.close();
                    }
                } finally {
                    tempInput.close();
                }

            } else {
                /// saving to external location,
                /// just copy the entry directly
                InputStream entryInput = sourceEntry.openInputStream();
                try {
                    OutputStream entryOutput = openEntryOutputStream(entryPath);
                    try {
                        FileUtils.transfer(entryInput, entryOutput, false);
                    } finally {
                        entryOutput.close();
                    }
                } finally {
                    entryInput.close();
                }
            }
        }

        if (usesWorkbookStorageAsOutputTarget && normalizerChanged) {
            /// keep the new normalizer in the original manifest
            /// to decrypt data in the internal storage afterwards
            manifest.setStreamNormalizer(newNormalizer);
        }

        /// save manifest.xml
        serializeXML(tempManifest, MANIFEST_XML);

        /// only upon success should we close zip stream
        if (intermediateOutputStream != null) {
            intermediateOutputStream.finish();
            intermediateOutputStream.flush();
            intermediateOutputStream.close();
        }

    }

    private static Document cloneDocument(Document document, String xmlName)
            throws CoreException {
        try {
            Transformer transformer = createXMLSerializer();
            DOMResult result = new DOMResult();
            transformer.transform(new DOMSource(document), result);
            return (Document) result.getNode();
        } catch (TransformerException e) {
            throw new CoreException(Core.ERROR_FAIL_SERIALIZING_XML, xmlName,
                    e);
        }
    }

    private static Transformer createXMLSerializer() throws CoreException {
        /// create a new transformer instance each time
        return DOMUtils.getDefaultTransformer();
    }

    private String makeTempPath(String path) {
        int sepIndex = path.lastIndexOf('/');
        if (sepIndex >= 0) {
            return path.substring(0, sepIndex + 1) + "._." //$NON-NLS-1$
                    + path.substring(sepIndex + 1);
        }
        // no separator
        return "._." + path; //$NON-NLS-1$
    }

    private void serializeXML(IAdaptable domAdaptable, String entryPath)
            throws IOException, CoreException {
        Node node = (Node) domAdaptable.getAdapter(Node.class);
        if (node == null)
            throw new CoreException(Core.ERROR_INVALID_ARGUMENT,
                    "Object has no DOM node"); //$NON-NLS-1$

        Transformer transformer = createXMLSerializer();
        OutputStream out = openEntryOutputStream(entryPath);
        try {
            transformer.transform(new DOMSource(node), new StreamResult(out));
        } catch (TransformerException e) {
            if (e.getCause() != null && e.getCause() instanceof IOException) {
                throw (IOException) e.getCause();
            }
            throw new CoreException(Core.ERROR_FAIL_SERIALIZING_XML, entryPath,
                    e);
        } finally {
            out.close();
        }

        serializedEntryPaths.add(entryPath);
    }

    private OutputStream openEntryOutputStream(String entryPath)
            throws IOException, CoreException {
        IFileEntry entry = tempManifest.getFileEntry(entryPath);

        if (isEntryEncryptionIgnored(entryPath)) {
            entry.deleteEncryptionData();
            return tempManifest.getStorage().getOutputTarget()
                    .openEntryStream(entryPath);
        }

        if (entry == null) {
            entry = tempManifest.createFileEntry(entryPath);
            entry.increaseReference();
        }
        return entry.openOutputStream();
    }

//    String calcChecksum(Object checksumProvider) {
//        if (checksumProvider instanceof IChecksumStream) {
//            return ((IChecksumStream) checksumProvider).getChecksum();
//        }
//        return null;
//    }

//    void recordChecksum(String entryPath, Object checksumProvider) {
//        String checksum = calcChecksum(checksumProvider);
//        if (checksum == null)
//            return;
//
//        IFileEntry entry = tempManifest.getFileEntry(entryPath);
//        if (entry == null)
//            return;
//
//        IEncryptionData encData = entry.getEncryptionData();
//        if (encData == null || encData.getChecksumType() == null)
//            return;
//
//        encData.setAttribute(checksum, DOMConstants.ATTR_CHECKSUM);
//    }

    private static class WriteOnlyStorage implements IStorage {

        private IOutputTarget target;

        public WriteOnlyStorage(IOutputTarget target) {
            this.target = target;
        }

        public IInputSource getInputSource() {
            throw new UnsupportedOperationException();
        }

        public IOutputTarget getOutputTarget() {
            return target;
        }

        public String getName() {
            return toString();
        }

        public String getFullPath() {
            return getName();
        }

        public void clear() {
        }

        public void deleteEntry(String entryName) {
        }

        public void renameEntry(String entryName, String newName) {
        }

    }

}
